iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
IT 管理

Playwright + Test Design + AI Agent:自動化測試實戰系列 第 8

第 08 天:遇強則強的獨孤九劍,使用測試模組降低技術債

  • 分享至 

  • xImage
  •  

獨孤九劍是獨孤求敗所創,在武林內是至高無上的劍法,總共有九式。以無用之用為大用為原則,根據觀察對方招式,迅速找到破綻,而攻擊方法沒有固定,完全視對手招式而定,遇強則強。在撰寫自動化測試,也有所謂的九式,在這之前,先介紹測試模組的概念,當作自動化測試的設計模式的基礎。

內功心法:增加重用性的測試模組

測試模組

在撰寫自動化測試時,隨著案例數量與業務複雜度上升,測試程式碼也容易變得凌亂。多數專案的基本操作其實能被重複使用;只要產品架構沒有劇烈變動,把這些操作抽成「測試模組」能大幅提升可讀性、可維護性與重複使用性。實務上,最重視的一點是:測試案例的「測試意圖」要被清楚表達。如果閱讀測試時,常常需要追到產品原始碼才能知道在測什麼,通常代表測試寫得太抽象或細節分散。

在自動化測試會需要的開發模組,通常可以包含:

  1. 使用者動作或是操作的相關函式 (Action methods): 例如:login(username, password)、addToCart(productId)。
  2. 驗證函式 (Assertion methods):例如 expectWelcome()、expectErrorContains(text)。
  3. 共用的工具 (Utilities):例如測試帳號生成、資料重置、攔截/偽造 API 回應等。

招式演練:從單一測試案例到模組化

先從一個最小可行的登入測試開始(未重構):

// tests/login-basic.spec.ts
import { test, expect } from '@playwright/test';

test('正常登入', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="username"]', 'testuser');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page.getByText('Welcome')).toBeVisible();
});

接著我們往往會加入更多登入相關案例:

  1. 正常使用者登入成功。
  2. 正常使用者輸入錯誤密碼,顯示「帳號或密碼錯誤」。
  3. 帳號不存在,顯示「帳號或密碼錯誤」。
  4. 帳號與密碼皆為空白,顯示「請輸入帳號與密碼」。
  5. 多次失敗導致帳號被鎖定,顯示「此帳號已被停用」。

這個範例可以執行並且得到正確的結果,這時會遇到三個典型痛點::

  1. 每個測試案例都需要登入流程
  2. 每個測試案例都可能會使用不同的使用者帳號密碼
  3. 測試程式碼裡面有大量的技術細節 "input[name='username']",無法直接理解測試案例的意圖。

因此,我們將一步步重構,將登入流程、帳號密碼並且隱藏細節,接著讓所有測試案例都可以使用到這個測試模組,而不用重複撰寫非測試案例無關的細節。

未重構版本(重複流程示範)

在開始重構之前,我們可以先看使用測試模組的測試案例會長得像怎麼樣子,裡面總共會有上述的五個測試案例:

未重構版本(五個檔案都重複登入流程)

// tests/userlogin.spec.ts
import { test, expect } from '@playwright/test';

test('正常登入', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="username"]', 'testuser');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page.getByText('Welcome')).toBeVisible();
});

test('錯誤密碼', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="username"]', 'testuser');
  await page.fill('input[name="password"]', 'wrongpass');
  await page.click('button[type="submit"]');
  await expect(page.locator('.error-message')).toHaveText(/帳號或密碼錯誤/);
});

test('錯誤帳號', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="username"]', 'nouser');
  await page.fill('input[name="password"]', 'any-pass');
  await page.click('button[type="submit"]');
  await expect(page.locator('.error-message')).toHaveText(/帳號或密碼錯誤/);
});

test('空白輸入', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.click('button[type="submit"]');
  await expect(page.locator('.error-message')).toHaveText(/請輸入帳號與密碼/);
});

test('帳號被鎖定', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('input[name="username"]', 'locked_user');
  await page.fill('input[name="password"]', 'any-pass');
  await page.click('button[type="submit"]');
  await expect(page.locator('.error-message')).toHaveText(/此帳號已被停用/);
});

開始重構(步驟導引)

最終版本,重構流程與步驟在後面。

// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../modules/login.page';

const CREDENTIALS = {
  ok: { user: 'testuser', pass: 'password123' },
  wrongPass: { user: 'testuser', pass: 'wrongpass' },
  wrongUser: { user: 'nouser', pass: 'any-pass' },
  locked: { user: 'locked_user', pass: 'any-pass' },
};

test.describe('登入測試(使用模組化)', () => {
  test('正常登入', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    await login.login(CREDENTIALS.ok.user, CREDENTIALS.ok.pass);
    await login.expectWelcome();
  });

  test('錯誤密碼', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    await login.login(CREDENTIALS.wrongPass.user, CREDENTIALS.wrongPass.pass);
    await login.expectErrorContains(/帳號或密碼錯誤/);
  });

  test('錯誤帳號', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    await login.login(CREDENTIALS.wrongUser.user, CREDENTIALS.wrongUser.pass);
    await login.expectErrorContains(/帳號或密碼錯誤/);
  });

  test('空白輸入', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    // 不輸入任何東西,直接送出
    await page.click('button[type="submit"]');
    await login.expectErrorContains(/請輸入帳號與密碼/);
  });

  test('帳號被鎖定', async ({ page }) => {
    const login = new LoginPage(page);
    await login.goto();
    await login.login(CREDENTIALS.locked.user, CREDENTIALS.locked.pass);
    await login.expectErrorContains(/此帳號已被停用/);
  });
});

開始重構

當我開始重構的時候,我會先專注於某個測試案例,接著應用在下一個測試案例,如果遇到重複的程式碼,則會抽取函式,如果含是有特殊目的,則會在獨立一個模組出來。

簡單來說,步驟能夠分成下列幾個步驟:

  1. 第一步:專注於第一個測試案例
  2. 第二步:應用在多個測試案例
  3. 第三步:抽取重複的程式碼,並且保留測試案例的測試意圖
  4. 第四步:抽取驗證的函式

重構目的

  1. 每個測試案例都需要登入流程
  2. 每個測試案例都可能會使用不同的使用者帳號密碼
  3. 測試程式碼裡面有大量的技術細節 "input[name='username']",無法直接理解測試案例的意圖。

第一步: 目標:保留測試案例「意圖」,把「操作細節」集中管理。

// modules/login.actions.ts
import { Page } from '@playwright/test';

export async function gotoLogin(page: Page) {
  await page.goto('https://example.com/login');
}

export async function doLogin(page: Page, username: string, password: string) {
  await page.fill('input[name="username"]', username);
  await page.fill('input[name="password"]', password);
  await page.click('button[type="submit"]');
}

第二步: 從函式抽取 → class 化

第一步我們只是抽出 login() function,第二步可以把「開啟登入頁」與「登入」兩個步驟,集中到一個 class 裡,讓測試更乾淨。

// modules/login.module.ts
import { Page } from '@playwright/test';

export class LoginModule {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('https://example.com/login');
  }

  async login(username: string, password: string) {
    await this.page.fill('input[name="username"]', username);
    await this.page.fill('input[name="password"]', password);
    await this.page.click('button[type="submit"]');
  }
}

// tests/login-basic.spec.ts
import { test, expect } from '@playwright/test';
import { LoginModule } from '../modules/login.module';

test('正常登入', async ({ page }) => {
  const login = new LoginModule(page);
  await login.goto();
  await login.login('testuser', 'password123');
  await expect(page.getByText('Welcome')).toBeVisible();
});

第三步: 加入驗證方法

為了讓測試更有語意,我們可以在模組裡增加「驗證」方法,讓測試案例只寫「意圖」。

// modules/login.module.ts
import { Page, expect } from '@playwright/test';

export class LoginModule {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('https://example.com/login');
  }

  async login(username: string, password: string) {
    await this.page.fill('input[name="username"]', username);
    await this.page.fill('input[name="password"]', password);
    await this.page.click('button[type="submit"]');
  }

  async expectWelcome() {
    await expect(this.page.getByText('Welcome')).toBeVisible();
  }

  async expectErrorContains(text: RegExp | string) {
    await expect(this.page.locator('.error-message')).toHaveText(text);
  }
}        
// tests/login-assertion.spec.ts
import { test } from '@playwright/test';
import { LoginModule } from '../modules/login.module';

test('正常登入', async ({ page }) => {
  const login = new LoginModule(page);
  await login.goto();
  await login.login('testuser', 'password123');
  await login.expectWelcome();
});

test('錯誤密碼', async ({ page }) => {
  const login = new LoginModule(page);
  await login.goto();
  await login.login('testuser', 'wrongpass');
  await login.expectErrorContains(/帳號或密碼錯誤/);
});

第四步:模組檔案化與擴充

經過第三步,我們就可以把 LoginModule 放在 modules/login.module.ts,測試案例只要 import 使用。這樣一來會發現我們將所有的 selector 和操作有關的細節,都放在模組裡面維護。測試案例只表達「測試意圖」,例如:使用者正常登入、使用錯誤密碼、帳號不存在,並且也將驗證的函式獨立出來,閱讀程式碼的人可以很清楚明白測試案例要驗證的目標是什麼。

經過這樣重構後的版本,如果將來需要新增多個驗證方法,例如:loginWithGoogle()、loginWithSSO(),我們也不需要改動舊有的測試案例。這裡我們暫時稱為測試模組。後面,我們會正式介紹這種寫法就是 Page Object Model (POM)。

秘笈傳授:專案結構

.
├─ modules/
│  └─ login.page.ts
├─ tests/
│  ├─ fixtures.ts
│  ├─ login-basic.spec.ts
│  ├─ login.spec.ts
│  └─ login-negative-ddt.spec.ts
├─ playwright.config.ts
└─ package.json

收功:今日總結

今天我們介紹什麼是測試模組,基本上就是將共同會使用的部分獨立抽出來變成一個模組,常用的模組有介面的操作、驗證函式或是基本的操作,通過這樣的操作可以讓多個測試案例共用不同的程式碼。這個測試模組是 Page Object Model (POM) 的前身。


上一篇
第 07 天:回顧:從點到線,化零為整
系列文
Playwright + Test Design + AI Agent:自動化測試實戰8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言